package daybook.io;

import daybook.exception.ApplicationException;
import daybook.exception.MissingCaseError;
import daybook.io.args.Args;
import daybook.io.formatter.*;
import daybook.io.shell.JsShell;
import daybook.io.shell.ShellContext;
import daybook.model.Account;
import daybook.model.Book;
import daybook.model.Commodity;
import daybook.model.transaction.*;
import daybook.util.Pair;
import daybook.util.StreamUtils;
import daybook.util.tree.Tree;
import org.json.JSONArray;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.script.ScriptException;
import java.io.*;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static daybook.model.transaction.TransactionSummary.*;

public class Runner {
    private static final Logger LOG = LoggerFactory.getLogger(Runner.class);
    private static final String BOOK_FILE_NAME = "book";
    private static final String LEDGER_FILE_NAME = "ledger";
    private static final String JOURNAL_FILE_NAME = "journal";
    private static final String BALANCE_SHEET_FILE_NAME = "balanceSheet";
    private static final String INCOME_STATEMENT_FILE_NAME = "incomeStatement";
    private static final String CASH_FLOW_FILE_NAME = "cashFlow";
    private static final String INVENTORY_FILE_NAME = "inventory";
    private static final String EXAMPLE_BOOK = "example/book.json";
    private static final String EXAMPLE_CONFIG = "example/config.json";
    private static final String EXAMPLE_DAYBOOK = "example/daybook.txt";

    private Book book;
    private List<Transaction> transactions;
    private GenerationConfig config;
    private JsShell shell;
    private FormatterFactory formatterFactory;

    public Book getBook() {
        return book;
    }

    public List<Transaction> getTransactions() {
        return transactions;
    }

    public GenerationConfig getConfig() {
        return config;
    }

    public void setBook(Book book) {
        this.book = book;
    }

    public void setTransactions(List<Transaction> transactions) {
        this.transactions = transactions;
    }

    public void setConfig(GenerationConfig config) {
        this.config = config;
    }

    public JsShell getShell() {
        return shell;
    }

    public void setShell(JsShell shell) {
        this.shell = shell;
    }

    public FormatterFactory getFormatterFactory() {
        return formatterFactory;
    }

    public void setFormatterFactory(FormatterFactory formatterFactory) {
        this.formatterFactory = formatterFactory;
    }

    public void load(Args args) {
        LOG.trace(">>> transactions");
        final Path bookPath = Paths.get(args.getCommonArgs().getBook());
        if (LOG.isInfoEnabled()) {
            LOG.info("=== transactions: loading book from {}", bookPath.toAbsolutePath().normalize());
        }
        try (final Reader reader = Files.newBufferedReader(bookPath)) {
            book = FromJson.book(new JSONObject(new JSONTokener(reader)));
            formatterFactory = new DefaultFormatterFactory(book);
        } catch (IOException e) {
            LOG.debug("=== transactions: io error when loading book", e);
            throw new UncheckedIOException(e);
        }
        LOG.info("=== transactions: loaded book");

        LOG.info("=== transactions: daybook format: {}", args.getCommonArgs().getFormat());
        final Path daybookPath = Paths.get(args.getCommonArgs().getFile()).toAbsolutePath();
        if (LOG.isInfoEnabled()) {
            LOG.info("=== transactions: loading daybook from {}", daybookPath.normalize());
        }
        switch (args.getCommonArgs().getFormat()) {
            case STRING: {
                try (final Reader reader = Files.newBufferedReader(daybookPath)) {
                    transactions = FromString.transactions(reader, book);
                } catch (IOException e) {
                    LOG.debug("=== transactions: io error when loading transactions", e);
                    throw new UncheckedIOException(e);
                }
                break;
            }
            case JSON: {
                try (final Reader reader = Files.newBufferedReader(daybookPath)) {
                    final JSONArray transactionJsons = new JSONArray(new JSONTokener(reader));
                    transactions = TransactionValidator.validate(StreamUtils.mapWithIndex(transactionJsons.iterator(),
                            (obj, index) -> FromJson.transaction((JSONObject) obj, index, book)), null, book.getDefaultCurrency())
                            .collect(Collectors.toList());
                } catch (IOException e) {
                    LOG.debug("=== transactions: io error when loading transactions", e);
                    throw new UncheckedIOException(e);
                }
                break;
            }
            default:
                throw new MissingCaseError(args.getCommonArgs().getFormat());
        }
        LOG.info("=== transactions: loaded daybook");

        final String command = args.getCommand();
        LOG.info("=== transactions: command: {}", command);
        if (Args.GENERATE.equals(command)) {
            final Path configPath = Paths.get(args.getGenerateArgs().getConfig());
            if (LOG.isInfoEnabled()) {
                LOG.info("=== transactions: loading config from {}", configPath.toAbsolutePath().normalize());
            }
            try (final Reader reader = Files.newBufferedReader(configPath)) {
                config = GenerationConfig.of(new JSONObject(new JSONTokener(reader)), book);
            } catch (IOException e) {
                LOG.debug("=== transactions: io error when loading generation config", e);
                throw new UncheckedIOException(e);
            }
            LOG.info("=== transactions: loaded config");
        }

        if (Args.RUN.equals(command) || Args.SHELL.equals(command)) {
            shell = new JsShell(new ShellContext(book, transactions));
            LOG.info("=== transactions: initialize javascript shell");
            shell.init();
        }
        LOG.trace("<<< transactions");
    }

    public void run(Args args) {
        LOG.trace(">>> run");
        final String command = args.getCommand();
        if (args.getCommonArgs().isHelp() || command == null) {
            args.usage();
            LOG.trace("<<< run");
            return;
        }

        if (!Args.INIT.equals(command)) {
            load(args);
        }

        LOG.info("=== command: {}", args.getCommand());
        switch (command) {
            case Args.INIT:
                init(args.getInitArgs().getDirectory());
                break;
            case Args.GENERATE:
                generate(args.getGenerateArgs().getOutputDirectory(),
                        args.getGenerateArgs().getFormat());
                break;
            case Args.STATUS:
                status(args.getStatusArgs().getFormat());
                break;
            case Args.PRINT:
                print(SimpleExpression.accountExpressionToSet(args.getPrintArgs().getAccounts().orElse(null),
                        args.getPrintArgs().isNegative(), book.getChartOfAccounts()),
                        args.getPrintArgs().getStartDate(),
                        args.getPrintArgs().getEndDate(),
                        args.getPrintArgs().getFormat());
                break;
            case Args.BOOK:
                printBook(args.getBookArgs().getFormat());
                break;
            case Args.TAGS:
                printTags(SimpleExpression.accountExpressionToSet(args.getTagsArgs().getAccounts().orElse(null),
                        args.getTagsArgs().isNegative(), book.getChartOfAccounts()),
                        args.getTagsArgs().getStartDate(),
                        args.getTagsArgs().getEndDate(),
                        args.getTagsArgs().getFormat());
                break;
            case Args.RUN:
                runScript(args.getRunArgs().getFiles());
                break;
            case Args.SHELL:
                runShell(args.getShellArgs().getFiles());
                break;
            default:
                args.usage();
                break;
        }
        LOG.trace("<<< run");
    }

    public void init(String directory) {
        LOG.trace(">>> init");
        try {
            final Path base = Paths.get(directory);
            if (LOG.isInfoEnabled()) {
                LOG.info("=== init: create an example daybook in {}", base.toAbsolutePath().normalize());
            }
            LOG.info("=== init: copying book");
            try (final InputStream book = ClassLoader.getSystemResourceAsStream(EXAMPLE_BOOK)) {
                Files.copy(book, base.resolve("book.json"));
            }
            LOG.info("=== init: copied book");
            LOG.info("=== init: copying config");
            try (final InputStream config = ClassLoader.getSystemResourceAsStream(EXAMPLE_CONFIG)) {
                Files.copy(config, base.resolve("config.json"));
            }
            LOG.info("=== init: copied config");
            LOG.info("=== init: copying daybook");
            try (final InputStream daybook = ClassLoader.getSystemResourceAsStream(EXAMPLE_DAYBOOK)) {
                Files.copy(daybook, base.resolve("daybook.txt"));
            }
            LOG.info("=== init: copied daybook");
        } catch (IOException e) {
            LOG.debug("=== init: io error when copying files", e);
            throw new UncheckedIOException(e);
        }

        LOG.trace("<<< init");
    }

    public void runShell(List<String> files) {
        LOG.trace(">>> runShell");
        runScript(files);
        LOG.info("=== runShell: starting repl");
        shell.repl();
        LOG.info("=== runShell: end repl");
        LOG.trace("<<< runShell");
    }

    public void runScript(List<String> files) {
        LOG.trace(">>> runScript");
        LOG.info("=== runScript: files: {}", files);

        for (String file: files) {
            final Path path = Paths.get(file);
            if (LOG.isDebugEnabled()) {
                LOG.debug("=== runScript: evaluating file: {}", path.toAbsolutePath().normalize());
            }
            try (final Reader reader = Files.newBufferedReader(path)) {
                shell.getEngine().eval(reader);
            } catch (IOException e) {
                LOG.debug("=== runScript: io error when reading file " + path, e);
                throw new UncheckedIOException(e);
            } catch (ScriptException e) {
                LOG.debug("=== runScript: error when evaluating javascript file " + path, e);
                throw new ApplicationException(e);
            }
        }
        LOG.trace("<<< runScript");
    }

    public void generateLedger(GenerationConfig.LedgerConfig ledgerConfig, Path base, Format format) {
        LOG.trace(">>> generateLedger");

        format = (format != null) ? format : ledgerConfig.format();
        if (LOG.isInfoEnabled()) {
            LOG.info("=== ledger: accounts: {}, output format: {}", SimpleExpression.accountsToString(ledgerConfig.accounts().orElse(null)), format);
        }

        final Map<Account, List<LedgerEntry>> ledger = ledgerConfig.accounts()
                .map(accounts -> accounts.isEmpty()
                        ? Collections.<Account, List<LedgerEntry>>emptyMap()
                        : ledger(transactions.stream(), accounts::contains))
                .orElse(ledger(transactions.stream()));

        formatterFactory.get(format).writeLedger(ledger, base, LEDGER_FILE_NAME);
        LOG.trace("<<< generateLedger");
    }

    public void generateJournal(GenerationConfig.JournalConfig journalConfig, Path base, Format format) {
        LOG.trace(">>> generateJournal");

        format = (format != null) ? format : journalConfig.format();
        if (LOG.isInfoEnabled()) {
            LOG.info("=== journal: accounts: {}, startDate: {}, endDate: {}, output format: {}",
                    SimpleExpression.accountsToString(journalConfig.accounts().orElse(null)), journalConfig.startDate(), journalConfig.endDate(), format);
        }

        final Stream<Transaction> txs = filterByDate(transactions, journalConfig.startDate(), journalConfig.endDate());
        final Stream<Transaction> stream = journalConfig.accounts()
                .map(accounts -> accounts.isEmpty()
                        ? Stream.<Transaction>empty()
                        : filterByAccounts(txs, accounts))
                .orElse(txs);

        formatterFactory.get(format).writeTransactions(stream, base, JOURNAL_FILE_NAME);
        LOG.trace("<<< generateJournal");
    }

    public void generateBalanceSheet(GenerationConfig.BalanceSheetConfig balanceSheetConfig, Path base, Format format) {
        LOG.trace(">>> generateBalanceSheet");

        format = (format != null) ? format : balanceSheetConfig.format();
        LOG.info("=== balance sheet: startDate: {}, endDate: {}, period: {}, ignoreZeroBalances: {}, output format: {}",
                balanceSheetConfig.startDate(), balanceSheetConfig.endDate(),
                balanceSheetConfig.period(), balanceSheetConfig.ignoreZeroBalances(), format);

        final NavigableSet<LocalDate> dates = new DateSet(balanceSheetConfig.startDate(),
                balanceSheetConfig.endDate(), balanceSheetConfig.period());
        final NavigableMap<LocalDate, Map<Account, BigDecimal>> balances = balance(transactions, dates);
        accumulateBalance(balances);
        final NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> balanceTrees = TransactionSummary.balanceTrees(balances, book.getChartOfAccounts(), balanceSheetConfig.ignoreZeroBalances());

        formatterFactory.get(format).writeBalanceSheet(balanceTrees, balanceSheetConfig.startDate(), base, BALANCE_SHEET_FILE_NAME);
        LOG.trace("<<< generateBalanceSheet");
    }

    public void generateIncomeStatement(GenerationConfig.IncomeStatementConfig incomeStatementConfig, Path base, Format format) {
        LOG.trace(">>> generateIncomeStatement");

        format = (format != null) ? format : incomeStatementConfig.format();
        LOG.info("=== income statement: startDate: {}, endDate: {}, period: {}, ignoreZeroBalances: {}, output format: {}",
                incomeStatementConfig.startDate(), incomeStatementConfig.endDate(),
                incomeStatementConfig.period(), incomeStatementConfig.ignoreZeroBalances(), format);

        final NavigableSet<LocalDate> dates = new DateSet(incomeStatementConfig.startDate(), incomeStatementConfig.endDate(),
                incomeStatementConfig.period());
        final NavigableMap<LocalDate, Map<Account, BigDecimal>> balances = balance(transactions, dates, incomeAccountPredicate());
        final NavigableMap<LocalDate, Tree<Pair<Account, BigDecimal>>> balanceTrees = TransactionSummary.balanceTrees(balances, book.getChartOfAccounts(), incomeStatementConfig.ignoreZeroBalances());

        formatterFactory.get(format).writeIncomeStatement(balanceTrees, incomeStatementConfig.startDate(), base, INCOME_STATEMENT_FILE_NAME);
        LOG.trace("<<< generateIncomeStatement");
    }

    public void generateCashFlow(GenerationConfig.CashFlowConfig cashFlowConfig, Path base, Format format) {
        LOG.trace(">>> generateCashFlow");
        if (cashFlowConfig.accounts().isEmpty()) {
            throw new IllegalArgumentException("not specify cash accounts");
        }

        format = (format != null) ? format : cashFlowConfig.format();
        LOG.info("=== cash flow: startDate: {}, endDate: {}, period: {}, output format: {}",
                cashFlowConfig.startDate(), cashFlowConfig.endDate(),
                cashFlowConfig.period(), format);

        final NavigableSet<LocalDate> dates = new DateSet(cashFlowConfig.startDate(), cashFlowConfig.endDate(), cashFlowConfig.period());
        final NavigableMap<LocalDate, Map<Boolean, Map<Account, BigDecimal[]>>> cashFlows = cashFlow(transactions, cashFlowConfig.accounts(), dates);

        formatterFactory.get(format).writeCashFlow(cashFlows, cashFlowConfig.startDate(), cashFlowConfig.accounts(), base, CASH_FLOW_FILE_NAME);
        LOG.trace("<<< generateCashFlow");
    }

    public void generateInventory(GenerationConfig.InventoryConfig inventoryConfig, Path base, Format format) {
        LOG.trace(">>> generateInventory");

        format = (format != null) ? format : inventoryConfig.format();
        if (LOG.isInfoEnabled()) {
            LOG.info("=== inventory: commodities: {}, output format: {}",
                    SimpleExpression.commoditiesToString(inventoryConfig.commodities().orElse(null)), format);
        }

        final Map<Commodity, List<InventoryEntry>> inventory = inventoryConfig.commodities()
                .map(commodities -> commodities.isEmpty()
                        ? Collections.<Commodity, List<InventoryEntry>>emptyMap()
                        : inventory(transactions.stream(), commodityPredicate(book.getDefaultCurrency(), commodities)))
                .orElse(inventory(transactions.stream(), commodityPredicate(book.getDefaultCurrency())));

        formatterFactory.get(format).writeInventory(inventory, base, INVENTORY_FILE_NAME);
        LOG.trace("<<< generateInventory");
    }

    public void generate(String outputDirectory, Format format) {
        LOG.trace(">>> generate");
        final Path base = Paths.get(outputDirectory);
        if (LOG.isInfoEnabled()) {
            LOG.info("=== generate: output directory: {}, output format: {}", base.toAbsolutePath().normalize(), format);
        }
        try {
            Files.createDirectories(base);
        } catch (IOException e) {
            LOG.debug("=== generate: io error when creating output directory", e);
        }
        config.ledger().ifPresent(ledgerConfig -> generateLedger(ledgerConfig, base, format));
        config.journal().ifPresent(journalConfig -> generateJournal(journalConfig, base, format));
        config.balanceSheet().ifPresent(balanceSheetConfig -> generateBalanceSheet(balanceSheetConfig, base, format));
        config.incomeStatement().ifPresent(incomeStatementConfig -> generateIncomeStatement(incomeStatementConfig, base, format));
        config.cashFlow().ifPresent(cashFlowConfig -> generateCashFlow(cashFlowConfig, base, format));
        config.inventory().ifPresent(inventoryConfig -> generateInventory(inventoryConfig, base, format));
        LOG.trace("<<< generate");
    }

    public void status(Format format) {
        LOG.trace(">>> status");
        LOG.info("=== status: output format: {}", format);
        final TransactionSummaryStatistics statistics = TransactionSummaryStatistics.ofOrderedTransactions(transactions);
        formatterFactory.get(format).printStatistics(statistics);
        LOG.trace("<<< status");
    }

    public  void printBook(Format format) {
        LOG.trace(">>> printBook");
        LOG.info("=== book: output format: {}", format);
        formatterFactory.get(format).printBook(book);
        LOG.trace("<<< printBook");
    }

    public  void printTags(Set<Account> accounts, LocalDate startDate, LocalDate endDate, Format format) {
        LOG.trace(">>> printTags");
        if (LOG.isInfoEnabled()) {
            LOG.info("=== tags: accounts: {}, startDate: {}, endDate: {}, output format: {}", SimpleExpression.accountsToString(accounts), startDate, endDate, format);
        }

        if (accounts != null && accounts.isEmpty()) {
            return;
        }
        Stream<Transaction> stream = filterByDate(transactions, startDate, endDate);
        final List<String> tags = accounts == null ? tags(stream) : tags(stream, accounts::contains);

        formatterFactory.get(format).printCollection(tags);
        LOG.trace("<<< printTags");
    }

    public void print(Set<Account> accounts, LocalDate startDate, LocalDate endDate, Format format) {
        LOG.trace(">>> print");
        if (LOG.isInfoEnabled()) {
            LOG.info("=== print: accounts: {}, startDate: {}, endDate: {}, output format: {}", SimpleExpression.accountsToString(accounts), startDate, endDate, format);
        }

        if (accounts != null && accounts.isEmpty()) {
            return;
        }
        Stream<Transaction> stream = filterByDate(transactions, startDate, endDate);
        stream = accounts == null ? stream : filterByAccounts(stream, accounts);

        formatterFactory.get(format).printTransactions(stream);
        LOG.trace("<<< print");
    }

    private static void setLogLevel(String levelStr) {
        final ch.qos.logback.classic.Level level = ch.qos.logback.classic.Level.toLevel(levelStr);
        final ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
        logger.setLevel(level);
    }

    public static void main(String[] args) {
        final Args arg = new Args(args);

        setLogLevel(arg.getCommonArgs().getLogLevel());
        LOG.trace(">>> main");

        final Runner runner = new Runner();
        try {
            runner.run(arg);
        } catch (ApplicationException e) {
            LOG.info("=== main: application exception", e);
            e.printStackTrace(System.err);
        } catch (UncheckedIOException e) {
            LOG.info("=== main: io exception", e.getCause());
            e.printStackTrace(System.err);
        } catch (Exception e) {
            LOG.info("=== main: unknown exception", e);
            e.printStackTrace(System.err);
        }
        LOG.trace("<<< main");
    }
}
